# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests the functionality of the cifuzz module."""
import os
import shutil
import sys
import tempfile
import unittest
from unittest import mock

import parameterized

# pylint: disable=wrong-import-position
INFRA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(INFRA_DIR)

OSS_FUZZ_DIR = os.path.dirname(INFRA_DIR)

import build_fuzzers
import continuous_integration
import repo_manager
import test_helpers

# NOTE: This integration test relies on
# https://github.com/google/oss-fuzz/tree/master/projects/example project.
EXAMPLE_PROJECT = 'example'

# Location of data used for testing.
TEST_DATA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                              'test_data')

# An example fuzzer that triggers an crash.
# Binary is a copy of the example project's do_stuff_fuzzer and can be
# generated by running "python3 infra/helper.py build_fuzzers example".
EXAMPLE_CRASH_FUZZER = 'example_crash_fuzzer'

# An example fuzzer that does not trigger a crash.
# Binary is a modified version of example project's do_stuff_fuzzer. It is
# created by removing the bug in my_api.cpp.
EXAMPLE_NOCRASH_FUZZER = 'example_nocrash_fuzzer'

# A fuzzer to be built in build_fuzzers integration tests.
EXAMPLE_BUILD_FUZZER = 'do_stuff_fuzzer'

# pylint: disable=no-self-use,protected-access,too-few-public-methods,unused-argument


class BuildFuzzersTest(unittest.TestCase):
  """Unit tests for build_fuzzers."""

  @mock.patch('build_specified_commit.detect_main_repo',
              return_value=('example.com', '/path'))
  @mock.patch('repo_manager._clone', return_value=None)
  @mock.patch('continuous_integration.checkout_specified_commit')
  @mock.patch('helper.docker_run', return_value=False)  # We want to quit early.
  def test_cifuzz_env_var(self, mock_docker_run, _, __, ___):
    """Tests that the CIFUZZ env var is set."""

    with tempfile.TemporaryDirectory() as tmp_dir:
      build_fuzzers.build_fuzzers(
          test_helpers.create_build_config(
              oss_fuzz_project_name=EXAMPLE_PROJECT,
              project_repo_name=EXAMPLE_PROJECT,
              workspace=tmp_dir,
              pr_ref='refs/pull/1757/merge'))

      docker_run_command = mock_docker_run.call_args_list[0][0][0]

    def command_has_env_var_arg(command, env_var_arg):
      for idx, element in enumerate(command):
        if idx == 0:
          continue

        if element == env_var_arg and command[idx - 1] == '-e':
          return True
      return False

    self.assertTrue(command_has_env_var_arg(docker_run_command, 'CIFUZZ=True'))


class InternalGithubBuildTest(unittest.TestCase):
  """Tests for building OSS-Fuzz projects on GitHub actions."""
  PROJECT_REPO_NAME = 'myproject'
  SANITIZER = 'address'
  GIT_SHA = 'fake'
  PR_REF = 'fake'

  def _create_builder(self, tmp_dir, oss_fuzz_project_name='myproject'):
    """Creates an InternalGithubBuilder and returns it."""
    config = test_helpers.create_build_config(
        oss_fuzz_project_name=oss_fuzz_project_name,
        project_repo_name=self.PROJECT_REPO_NAME,
        workspace=tmp_dir,
        sanitizer=self.SANITIZER,
        git_sha=self.GIT_SHA,
        pr_ref=self.PR_REF,
        cfl_platform='github')
    cfl_platform = continuous_integration.get_ci(config)
    builder = build_fuzzers.Builder(config, cfl_platform)
    builder.repo_manager = repo_manager.RepoManager('/fake')
    return builder

  @mock.patch('helper.docker_run', return_value=True)
  @mock.patch('continuous_integration.checkout_specified_commit',
              side_effect=None)
  def test_correct_host_repo_path(self, _, __):
    """Tests that the correct self.host_repo_path is set by
    build_image_and_checkout_src. Specifically, we want the name of the
    directory the repo is in to match the name used in the docker
    image/container, so that it will replace the host's copy properly."""
    image_repo_path = '/src/repo_dir'
    with tempfile.TemporaryDirectory() as tmp_dir, mock.patch(
        'build_specified_commit.detect_main_repo',
        return_value=('inferred_url', image_repo_path)):
      builder = self._create_builder(tmp_dir)
      builder.build_image_and_checkout_src()

    self.assertEqual(os.path.basename(builder.host_repo_path),
                     os.path.basename(image_repo_path))

  @mock.patch('clusterfuzz_deployment.ClusterFuzzLite.upload_build',
              return_value=True)
  def test_upload_build_disabled(self, mock_upload_build):
    """Test upload build (disabled)."""
    with tempfile.TemporaryDirectory() as tmp_dir:
      builder = self._create_builder(tmp_dir)
      builder.upload_build()

    mock_upload_build.assert_not_called()

  @mock.patch('repo_manager.RepoManager.get_current_commit',
              return_value='commit')
  @mock.patch('clusterfuzz_deployment.ClusterFuzzLite.upload_build',
              return_value=True)
  def test_upload_build(self, mock_upload_build, mock_get_current_commit):
    """Test upload build."""
    with tempfile.TemporaryDirectory() as tmp_dir:
      builder = self._create_builder(tmp_dir, oss_fuzz_project_name='')
      builder.config.upload_build = True
      builder.upload_build()

    mock_upload_build.assert_called_with('commit')


@unittest.skipIf(not os.getenv('INTEGRATION_TESTS'),
                 'INTEGRATION_TESTS=1 not set')
class BuildFuzzersIntegrationTest(unittest.TestCase):
  """Integration tests for build_fuzzers."""

  def setUp(self):
    self.temp_dir_ctx_manager = test_helpers.docker_temp_dir()
    self.workspace = self.temp_dir_ctx_manager.__enter__()
    self.out_dir = os.path.join(self.workspace, 'build-out')
    test_helpers.patch_environ(self)

    base_runner_path = os.path.join(INFRA_DIR, 'base-images', 'base-runner')
    os.environ['PATH'] = os.environ['PATH'] + os.pathsep + base_runner_path

  def tearDown(self):
    self.temp_dir_ctx_manager.__exit__(None, None, None)

  def test_external_github_project(self):
    """Tests building fuzzers from an external project on Github."""
    project_repo_name = 'external-project'
    git_url = 'https://github.com/jonathanmetzman/cifuzz-external-example.git'
    # This test is dependant on the state of
    # github.com/jonathanmetzman/cifuzz-external-example.
    config = test_helpers.create_build_config(
        project_repo_name=project_repo_name,
        workspace=self.workspace,
        git_url=git_url,
        git_sha='HEAD',
        cfl_platform='github',
        base_commit='HEAD^1')
    self.assertTrue(build_fuzzers.build_fuzzers(config))
    self.assertTrue(
        os.path.exists(os.path.join(self.out_dir, EXAMPLE_BUILD_FUZZER)))

  def test_external_generic_project(self):
    """Tests building fuzzers from an external project not on Github."""
    project_repo_name = 'cifuzz-external-example'
    git_url = 'https://github.com/jonathanmetzman/cifuzz-external-example.git'
    # This test is dependant on the state of
    # github.com/jonathanmetzman/cifuzz-external-example.
    manager = repo_manager.clone_repo_and_get_manager(
        'https://github.com/jonathanmetzman/cifuzz-external-example',
        self.workspace)
    project_src_path = manager.repo_dir
    config = test_helpers.create_build_config(
        project_repo_name=project_repo_name,
        workspace=self.workspace,
        git_url=git_url,
        filestore='no_filestore',
        git_sha='HEAD',
        project_src_path=project_src_path,
        base_commit='HEAD^1')
    self.assertTrue(build_fuzzers.build_fuzzers(config))
    self.assertTrue(
        os.path.exists(os.path.join(self.out_dir, EXAMPLE_BUILD_FUZZER)))

  def test_valid_commit(self):
    """Tests building fuzzers with valid inputs."""
    config = test_helpers.create_build_config(
        oss_fuzz_project_name=EXAMPLE_PROJECT,
        project_repo_name='oss-fuzz',
        workspace=self.workspace,
        git_sha='0b95fe1039ed7c38fea1f97078316bfc1030c523',
        base_commit='da0746452433dc18bae699e355a9821285d863c8',
        cfl_platform='github')
    self.assertTrue(build_fuzzers.build_fuzzers(config))
    self.assertTrue(
        os.path.exists(os.path.join(self.out_dir, EXAMPLE_BUILD_FUZZER)))

  def test_valid_pull_request(self):
    """Tests building fuzzers with valid pull request."""
    config = test_helpers.create_build_config(
        oss_fuzz_project_name=EXAMPLE_PROJECT,
        project_repo_name='oss-fuzz',
        workspace=self.workspace,
        pr_ref='refs/pull/1757/merge',
        base_ref='master',
        cfl_platform='github')
    self.assertTrue(build_fuzzers.build_fuzzers(config))
    self.assertTrue(
        os.path.exists(os.path.join(self.out_dir, EXAMPLE_BUILD_FUZZER)))

  def test_invalid_pull_request(self):
    """Tests building fuzzers with invalid pull request."""
    config = test_helpers.create_build_config(
        oss_fuzz_project_name=EXAMPLE_PROJECT,
        project_repo_name='oss-fuzz',
        workspace=self.workspace,
        pr_ref='ref-1/merge',
        base_ref='master',
        cfl_platform='github')
    self.assertTrue(build_fuzzers.build_fuzzers(config))

  def test_invalid_oss_fuzz_project_name(self):
    """Tests building fuzzers with invalid project name."""
    config = test_helpers.create_build_config(
        oss_fuzz_project_name='not_a_valid_project',
        project_repo_name='oss-fuzz',
        workspace=self.workspace,
        git_sha='0b95fe1039ed7c38fea1f97078316bfc1030c523')
    self.assertFalse(build_fuzzers.build_fuzzers(config))

  def test_invalid_repo_name(self):
    """Tests building fuzzers with invalid repo name."""
    config = test_helpers.create_build_config(
        oss_fuzz_project_name=EXAMPLE_PROJECT,
        project_repo_name='not-real-repo',
        workspace=self.workspace,
        git_sha='0b95fe1039ed7c38fea1f97078316bfc1030c523')
    self.assertFalse(build_fuzzers.build_fuzzers(config))

  def test_invalid_git_sha(self):
    """Tests building fuzzers with invalid commit SHA."""
    config = test_helpers.create_build_config(
        oss_fuzz_project_name=EXAMPLE_PROJECT,
        project_repo_name='oss-fuzz',
        workspace=self.workspace,
        git_sha='',
        cfl_platform='github')
    with self.assertRaises(AssertionError):
      build_fuzzers.build_fuzzers(config)

  def test_invalid_workspace(self):
    """Tests building fuzzers with invalid workspace."""
    config = test_helpers.create_build_config(
        oss_fuzz_project_name=EXAMPLE_PROJECT,
        project_repo_name='oss-fuzz',
        workspace=os.path.join(self.workspace, 'not', 'a', 'dir'),
        git_sha='0b95fe1039ed7c38fea1f97078316bfc1030c523')
    self.assertFalse(build_fuzzers.build_fuzzers(config))


class CheckFuzzerBuildTest(unittest.TestCase):
  """Tests the check_fuzzer_build function in the cifuzz module."""

  SANITIZER = 'address'
  LANGUAGE = 'c++'

  def setUp(self):
    self.temp_dir_obj = tempfile.TemporaryDirectory()
    workspace_path = os.path.join(self.temp_dir_obj.name, 'workspace')
    self.config = test_helpers.create_build_config(
        oss_fuzz_project_name=EXAMPLE_PROJECT,
        sanitizer=self.SANITIZER,
        language=self.LANGUAGE,
        workspace=workspace_path,
        pr_ref='refs/pull/1757/merge')
    self.workspace = test_helpers.create_workspace(workspace_path)
    shutil.copytree(TEST_DATA_PATH, workspace_path)
    test_helpers.patch_environ(self, runner=True)

  def tearDown(self):
    self.temp_dir_obj.cleanup()

  def test_correct_fuzzer_build(self):
    """Checks check_fuzzer_build function returns True for valid fuzzers."""
    self.assertTrue(build_fuzzers.check_fuzzer_build(self.config))

  def test_not_a_valid_path(self):
    """Tests that False is returned when a nonexistent path is given."""
    self.config.workspace = 'not/a/valid/path'
    self.assertFalse(build_fuzzers.check_fuzzer_build(self.config))

  def test_no_valid_fuzzers(self):
    """Tests that False is returned when an empty directory is given."""
    with tempfile.TemporaryDirectory() as tmp_dir:
      self.config.workspace = tmp_dir
      os.mkdir(os.path.join(self.config.workspace, 'build-out'))
      self.assertFalse(build_fuzzers.check_fuzzer_build(self.config))

  @mock.patch('utils.execute', return_value=(None, None, 0))
  def test_allow_broken_fuzz_targets_percentage(self, mock_execute):
    """Tests that ALLOWED_BROKEN_TARGETS_PERCENTAGE is set when running
    docker if passed to check_fuzzer_build."""
    percentage = '0'
    self.config.allowed_broken_targets_percentage = percentage
    build_fuzzers.check_fuzzer_build(self.config)
    self.assertEqual(
        mock_execute.call_args[1]['env']['ALLOWED_BROKEN_TARGETS_PERCENTAGE'],
        percentage)


@unittest.skip('Test is too long to be run with presubmit.')
class BuildSantizerIntegrationTest(unittest.TestCase):
  """Integration tests for the build_fuzzers.
    Note: This test relies on "curl" being an OSS-Fuzz project."""
  PROJECT_NAME = 'curl'
  PR_REF = 'fake_pr'

  @classmethod
  def _create_config(cls, tmp_dir, sanitizer):
    return test_helpers.create_build_config(
        oss_fuzz_project_name=cls.PROJECT_NAME,
        project_repo_name=cls.PROJECT_NAME,
        workspace=tmp_dir,
        pr_ref=cls.PR_REF,
        sanitizer=sanitizer)

  @parameterized.parameterized.expand([('memory',), ('undefined',)])
  def test_valid_project_curl(self, sanitizer):
    """Tests that MSAN can be detected from project.yaml"""
    with tempfile.TemporaryDirectory() as tmp_dir:
      self.assertTrue(
          build_fuzzers.build_fuzzers(self._create_config(tmp_dir, sanitizer)))


class GetDockerBuildFuzzersArgsNotContainerTest(unittest.TestCase):
  """Tests that _get_docker_build_fuzzers_args_not_container works as
  intended."""

  def test_get_docker_build_fuzzers_args_no_container(self):
    """Tests that _get_docker_build_fuzzers_args_not_container works
    as intended."""
    host_repo_path = '/host/repo'
    result = build_fuzzers._get_docker_build_fuzzers_args_not_container(
        host_repo_path)
    expected_result = ['-v', '/host/repo:/host/repo']
    self.assertEqual(result, expected_result)


if __name__ == '__main__':
  unittest.main()
